﻿// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

#pragma warning disable RS1035 // Do not use APIs banned for analyzers (old style source generator)
#pragma warning disable RS1042 // Deprecated interface

using System.Text.Json;
using Microsoft.CodeAnalysis.Text;

namespace Microsoft.Windows.CsWin32;

/// <summary>
/// Generates the source code for the p/invoke methods and supporting types into some C# project.
/// </summary>
#pragma warning disable RS1041
[Generator]
#pragma warning restore RS1041
public partial class SourceGenerator : ISourceGenerator
{
#pragma warning disable CS1591 // Missing XML comment for publicly visible type or member
    public static readonly DiagnosticDescriptor InternalError = new DiagnosticDescriptor(
        "PInvoke000",
        "CsWin32InternalError",
        "An internal error occurred: {0}",
        "Functionality",
        DiagnosticSeverity.Error,
        isEnabledByDefault: true);

    public static readonly DiagnosticDescriptor NoMatchingMethodOrType = new DiagnosticDescriptor(
        "PInvoke001",
        "No matching method, type or constant found",
        "Method, type or constant \"{0}\" not found",
        "Functionality",
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true);

    public static readonly DiagnosticDescriptor NoMatchingMethodOrTypeWithBadCharacters = new DiagnosticDescriptor(
        "PInvoke001",
        "No matching method, type or constant found",
        "Method, type or constant \"{0}\" not found. It contains unexpected characters, possibly including invisible characters, which can happen when copying and pasting from learn.microsoft.com among other places. Try deleting the line and retyping it.",
        "Functionality",
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true);

    public static readonly DiagnosticDescriptor NoMatchingMethodOrTypeWithSuggestions = new DiagnosticDescriptor(
        "PInvoke001",
        "No matching method, type or constant found",
#pragma warning disable RS1032 // end with a period
        "Method, type or constant \"{0}\" not found. Did you mean {1}?",
#pragma warning restore RS1032 // end with a period
        "Functionality",
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true);

    public static readonly DiagnosticDescriptor NoMethodsForModule = new DiagnosticDescriptor(
        "PInvoke001",
        "No module found",
        "No methods found under module \"{0}\"",
        "Functionality",
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true);

    public static readonly DiagnosticDescriptor UnsafeCodeRequired = new DiagnosticDescriptor(
        "PInvoke002",
        "AllowUnsafeCode",
        "AllowUnsafeBlocks must be set to 'true' in the project file for many APIs. Compiler errors may result.",
        "Functionality",
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: "Many generated types or P/Invoke methods require use of pointers, so the receiving compilation must allow unsafe code.");

    public static readonly DiagnosticDescriptor BannedApi = new DiagnosticDescriptor(
        "PInvoke003",
        "BannedAPI",
#pragma warning disable RS1032 // end with a period
        "This API will not be generated. {0}",
#pragma warning restore RS1032 // end with a period
        "Functionality",
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true);

    public static readonly DiagnosticDescriptor UseEnumValueDeclaringType = new DiagnosticDescriptor(
        "PInvoke004",
        "UseEnumDeclaringType",
        "Use the name of the enum that declares this constant: {0}",
        "Functionality",
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true,
        description: "Constants that are defined within enums should be generated by requesting the name of their declaring enum instead.");

    public static readonly DiagnosticDescriptor CpuArchitectureIncompatibility = new DiagnosticDescriptor(
        "PInvoke005",
        "TargetSpecificCpuArchitecture",
        "This API is only available when targeting a specific CPU architecture. AnyCPU cannot generate this API.",
        "Functionality",
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true);

    public static readonly DiagnosticDescriptor DocParsingError = new DiagnosticDescriptor(
        "PInvoke006",
        "DocsParseError",
        "An error occurred while reading docs file: \"{0}\": {1}",
        "Configuration",
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true);

    public static readonly DiagnosticDescriptor AmbiguousMatchError = new DiagnosticDescriptor(
        "PInvoke007",
        "AmbiguousMatch",
        "The API \"{0}\" is ambiguous",
        "Functionality",
        DiagnosticSeverity.Error,
        isEnabledByDefault: true);

    public static readonly DiagnosticDescriptor AmbiguousMatchErrorWithSuggestions = new DiagnosticDescriptor(
        "PInvoke007",
        "AmbiguousMatch",
        "The API \"{0}\" is ambiguous. Please specify one of: {1}.",
        "Functionality",
        DiagnosticSeverity.Error,
        isEnabledByDefault: true);

    public static readonly DiagnosticDescriptor OptionsParsingError = new DiagnosticDescriptor(
        "PInvoke008",
        "BadOptions",
        "An error occurred while parsing \"{0}\": {1}",
        "Configuration",
        DiagnosticSeverity.Error,
        isEnabledByDefault: true);

    public static readonly DiagnosticDescriptor MissingRecommendedReference = new(
        "PInvoke009",
        "Missing package reference",
        "Missing reference to recommended package: \"{0}\"",
        "Configuration",
        DiagnosticSeverity.Warning,
        isEnabledByDefault: true);

    public static readonly DiagnosticDescriptor NonUniqueMetadataInputs = new(
        InputProjectionErrorId,
        InputProjectionErrorTitle,
        "The metadata projections input into CsWin32 must have unique names. The name \"{0}\" is used more than once.",
        "Configuration",
        DiagnosticSeverity.Error,
        isEnabledByDefault: true);
#pragma warning restore CS1591 // Missing XML comment for publicly visible type or member

    private const string InputProjectionErrorId = "PInvoke010";
    private const string InputProjectionErrorTitle = "Input projection error";

    private const string NativeMethodsTxtAdditionalFileName = "NativeMethods.txt";
    private const string NativeMethodsJsonAdditionalFileName = "NativeMethods.json";

    private static readonly char[] ZeroWhiteSpace = new char[]
    {
        '\uFEFF', // ZERO WIDTH NO-BREAK SPACE (U+FEFF)
        '\u200B', // ZERO WIDTH SPACE (U+200B)
    };

    private static readonly JsonSerializerOptions JsonOptions = new JsonSerializerOptions
    {
        AllowTrailingCommas = true,
        ReadCommentHandling = JsonCommentHandling.Skip,
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    };

    /// <inheritdoc/>
    public void Initialize(GeneratorInitializationContext context)
    {
    }

    /// <inheritdoc/>
    public void Execute(GeneratorExecutionContext context)
    {
        if (context.Compilation is not CSharpCompilation compilation)
        {
            return;
        }

        if (GetRunAsBuildTaskProperty(context))
        {
            // When running as a build task, we don't want to generate anything here.
            return;
        }

        GeneratorOptions? options;
        AdditionalText? nativeMethodsJsonFile = context.AdditionalFiles
            .FirstOrDefault(af => Path.GetFileName(af.Path).EndsWith(NativeMethodsJsonAdditionalFileName, StringComparison.OrdinalIgnoreCase));
        if (nativeMethodsJsonFile is object)
        {
            string optionsJson = nativeMethodsJsonFile.GetText(context.CancellationToken)!.ToString();
            try
            {
                options = JsonSerializer.Deserialize<GeneratorOptions>(optionsJson, JsonOptions) ?? new();
            }
            catch (JsonException ex)
            {
                context.ReportDiagnostic(Diagnostic.Create(OptionsParsingError, location: null, nativeMethodsJsonFile.Path, ex.Message));
                return;
            }
        }
        else
        {
            options = new GeneratorOptions();
        }

        IEnumerable<AdditionalText> nativeMethodsTxtFiles = context.AdditionalFiles
            .Where(af => Path.GetFileName(af.Path).EndsWith(NativeMethodsTxtAdditionalFileName, StringComparison.OrdinalIgnoreCase));
        if (!nativeMethodsTxtFiles.Any())
        {
            return;
        }

        var parseOptions = (CSharpParseOptions)context.ParseOptions;

        if (!compilation.Options.AllowUnsafe)
        {
            context.ReportDiagnostic(Diagnostic.Create(UnsafeCodeRequired, location: null));
        }

        if (compilation.GetTypeByMetadataName("System.Memory`1") is null)
        {
            context.ReportDiagnostic(Diagnostic.Create(MissingRecommendedReference, location: null, "System.Memory"));
        }

        IEnumerable<string> appLocalLibraries = CollectAppLocalAllowedLibraries(context);
        Docs? docs = ParseDocs(context);
        Generator[] generators = CollectMetadataPaths(context).Select(path => new Generator(path, docs, appLocalLibraries, options, compilation, parseOptions)).ToArray();
        if (TryFindNonUniqueValue(generators, g => g.InputAssemblyName, StringComparer.OrdinalIgnoreCase, out (Generator Item, string Value) nonUniqueGenerator))
        {
            context.ReportDiagnostic(Diagnostic.Create(NonUniqueMetadataInputs, null, nonUniqueGenerator.Value));
            return;
        }

        using SuperGenerator superGenerator = SuperGenerator.Combine(generators);

        List<(AdditionalText, SourceText)> nativeMethodsTxts = new();

        foreach (AdditionalText nativeMethodsTxtFile in nativeMethodsTxtFiles)
        {
            SourceText? nativeMethodsTxt = nativeMethodsTxtFile.GetText(context.CancellationToken);
            if (nativeMethodsTxt is null)
            {
                return;
            }

            foreach (TextLine line in nativeMethodsTxt.Lines)
            {
                string name = line.ToString();
                if (name.StartsWith("-", StringComparison.InvariantCulture))
                {
                    superGenerator.AddGeneratorExclusion(name.Substring(1).Trim());
                }
            }

            nativeMethodsTxts.Add((nativeMethodsTxtFile, nativeMethodsTxt));
        }

        foreach (var (nativeMethodsTxtFile, nativeMethodsTxt) in nativeMethodsTxts)
        {
            foreach (TextLine line in nativeMethodsTxt.Lines)
            {
                context.CancellationToken.ThrowIfCancellationRequested();
                string name = line.ToString();
                if (string.IsNullOrWhiteSpace(name) || name.StartsWith("//", StringComparison.InvariantCulture) || name.StartsWith("-", StringComparison.InvariantCulture))
                {
                    continue;
                }

                name = name.Trim().Trim(ZeroWhiteSpace);
                var location = Location.Create(nativeMethodsTxtFile.Path, line.Span, nativeMethodsTxt.Lines.GetLinePositionSpan(line.Span));
                try
                {
                    if (Generator.GetBannedAPIs(options).TryGetValue(name, out string? reason))
                    {
                        context.ReportDiagnostic(Diagnostic.Create(BannedApi, location, reason));
                        continue;
                    }

                    if (name.EndsWith(".*", StringComparison.Ordinal))
                    {
                        string? moduleName = name.Substring(0, name.Length - 2);
                        int matches = superGenerator.TryGenerateAllExternMethods(moduleName, context.CancellationToken);
                        switch (matches)
                        {
                            case 0:
                                context.ReportDiagnostic(Diagnostic.Create(NoMethodsForModule, location, moduleName));
                                break;
                            case > 1:
                                // Stop complaining about multiple metadata exporting methods from the same module.
                                // https://github.com/microsoft/CsWin32/issues/1201
                                ////context.ReportDiagnostic(Diagnostic.Create(AmbiguousMatchError, location, moduleName));
                                break;
                        }

                        continue;
                    }

                    superGenerator.TryGenerate(name, out IReadOnlyCollection<string> matchingApis, out IReadOnlyCollection<string> redirectedEnums, context.CancellationToken);
                    foreach (string declaringEnum in redirectedEnums)
                    {
                        context.ReportDiagnostic(Diagnostic.Create(UseEnumValueDeclaringType, location, declaringEnum));
                    }

                    switch (matchingApis.Count)
                    {
                        case 0:
                            ReportNoMatch(location, name);
                            break;
                        case > 1:
                            context.ReportDiagnostic(Diagnostic.Create(AmbiguousMatchErrorWithSuggestions, location, name, ConcatSuggestions(matchingApis)));
                            break;
                    }
                }
                catch (GenerationFailedException ex)
                {
                    if (Generator.IsPlatformCompatibleException(ex))
                    {
                        context.ReportDiagnostic(Diagnostic.Create(CpuArchitectureIncompatibility, location));
                    }
                    else
                    {
                        // Build up a complete error message.
                        context.ReportDiagnostic(Diagnostic.Create(InternalError, location, AssembleFullExceptionMessage(ex)));
                    }
                }
            }
        }

        foreach (KeyValuePair<string, CompilationUnitSyntax> unit in superGenerator.GetCompilationUnits(context.CancellationToken))
        {
            context.AddSource(unit.Key, unit.Value.GetText(Encoding.UTF8));
        }

        string ConcatSuggestions(IReadOnlyCollection<string> suggestions)
        {
            var suggestionBuilder = new StringBuilder();
            int i = 0;
            foreach (string suggestion in suggestions)
            {
                if (++i > 0)
                {
                    suggestionBuilder.Append(i < suggestions.Count - 1 ? ", " : " or ");
                }

                suggestionBuilder.Append('"');
                suggestionBuilder.Append(suggestion);
                suggestionBuilder.Append('"');
            }

            return suggestionBuilder.ToString();
        }

        void ReportNoMatch(Location? location, string failedAttempt)
        {
            IReadOnlyList<string> suggestions = superGenerator.GetSuggestions(failedAttempt);
            if (suggestions.Count > 0)
            {
                context.ReportDiagnostic(Diagnostic.Create(NoMatchingMethodOrTypeWithSuggestions, location, failedAttempt, ConcatSuggestions(suggestions)));
            }
            else
            {
                context.ReportDiagnostic(Diagnostic.Create(
                    Generator.ContainsIllegalCharactersForAPIName(failedAttempt) ? NoMatchingMethodOrTypeWithBadCharacters : NoMatchingMethodOrType,
                    location,
                    failedAttempt));
            }
        }
    }

    private static string AssembleFullExceptionMessage(Exception ex)
    {
        var sb = new StringBuilder();

        Exception? inner = ex;
        while (inner is object)
        {
            sb.Append(inner.Message);
            if (sb.Length > 0 && sb[sb.Length - 1] != '.')
            {
                sb.Append('.');
            }

            sb.Append(' ');
            inner = inner.InnerException;
        }

        sb.AppendLine();
        sb.AppendLine(ex.ToString());

        return sb.ToString();
    }

    private static bool TryFindNonUniqueValue<T, TValue>(IEnumerable<T> sequence, Func<T, TValue> valueSelector, IEqualityComparer<TValue> comparer, out (T Item, TValue Value) nonUniqueValue)
    {
        HashSet<TValue> seenValues = new(comparer);
        nonUniqueValue = default;
        foreach (T item in sequence)
        {
            TValue value = valueSelector(item);
            if (!seenValues.Add(value))
            {
                nonUniqueValue = (item, value);
                return true;
            }
        }

        return false;
    }

    private static IReadOnlyList<string> CollectMetadataPaths(GeneratorExecutionContext context)
    {
        if (!context.AnalyzerConfigOptions.GlobalOptions.TryGetValue("build_property.CsWin32InputMetadataPaths", out string? delimitedMetadataBasePaths) ||
            string.IsNullOrWhiteSpace(delimitedMetadataBasePaths))
        {
            return Array.Empty<string>();
        }

        string[] metadataBasePaths = delimitedMetadataBasePaths.Split('|');
        return metadataBasePaths;
    }

    private static IEnumerable<string> CollectAppLocalAllowedLibraries(GeneratorExecutionContext context)
    {
        if (!context.AnalyzerConfigOptions.GlobalOptions.TryGetValue("build_property.CsWin32AppLocalAllowedLibraries", out string? delimitedAppLocalLibraryPaths) ||
            string.IsNullOrWhiteSpace(delimitedAppLocalLibraryPaths))
        {
            return Array.Empty<string>();
        }

        return delimitedAppLocalLibraryPaths?.Split('|').Select(x => Path.GetFileName(x)!) ?? Array.Empty<string>();
    }

    private static Docs? ParseDocs(GeneratorExecutionContext context)
    {
        Docs? docs = null;
        if (context.AnalyzerConfigOptions.GlobalOptions.TryGetValue("build_property.CsWin32InputDocPaths", out string? delimitedApiDocsPaths) &&
            !string.IsNullOrWhiteSpace(delimitedApiDocsPaths))
        {
            string[] apiDocsPaths = delimitedApiDocsPaths!.Split('|');
            if (apiDocsPaths.Length > 0)
            {
                List<Docs> docsList = new(apiDocsPaths.Length);
                foreach (string path in apiDocsPaths)
                {
                    try
                    {
                        docsList.Add(Docs.Get(path));
                    }
                    catch (Exception e)
                    {
                        context.ReportDiagnostic(Diagnostic.Create(DocParsingError, null, path, e));
                    }
                }

                docs = Docs.Merge(docsList);
            }
        }

        return docs;
    }

    private static bool GetRunAsBuildTaskProperty(GeneratorExecutionContext context)
    {
        if (!context.AnalyzerConfigOptions.GlobalOptions.TryGetValue("build_property.CsWin32RunAsBuildTask", out string? runAsBuildTask) ||
            string.IsNullOrWhiteSpace(runAsBuildTask))
        {
            return false;
        }

        return bool.TryParse(runAsBuildTask, out bool result) && result;
    }
}
